AWSTemplateFormatVersion: '2010-09-09'
Description: >
  S3 Baseline Lifecycle Remediator.
  Purpose-specific, opt-in remediation module for identifying S3 buckets without
  lifecycle configuration and optionally applying a conservative baseline lifecycle
  rule. The default baseline only aborts incomplete multipart uploads and does not
  expire or transition completed objects.

Parameters:
  ProjectName:
    Type: String
    Default: s3-baseline-lifecycle-remediator
    Description: Base name used for created resources.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would be changed. When false, apply eligible lifecycle configuration.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: >
      When true, only modify candidate buckets tagged with ApprovalTagKey=ApprovalTagValue.
      For large backlog cleanup, set false only after reviewing dry-run results.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotModify,DoNotDelete
    Description: Comma-separated tag keys that prevent modification when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotModify=true,DoNotDelete=true
    Description: Comma-separated key=value tag pairs that prevent modification when present.

  OnlyIfNoLifecycle:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: >
      When true, only modifies buckets that have no lifecycle configuration. Recommended true.
      Existing lifecycle policies are skipped to avoid accidental overwrites.

  AllowOverwriteExistingLifecycle:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: >
      When true, allows replacing existing lifecycle configuration. Not recommended for normal use.
      This template does not merge existing complex lifecycle rules.

  AbortIncompleteMultipartUploadDays:
    Type: Number
    Default: 7
    Description: Number of days after initiation to abort incomplete multipart uploads.

  LifecycleRuleId:
    Type: String
    Default: CostOpt-AbortIncompleteMultipartUploads
    Description: Lifecycle rule ID used for the baseline rule.

  ExcludedBucketNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated bucket name prefixes to skip.

  IncludedBucketNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated bucket name prefixes to include. Leave blank to include all buckets.

  MaxBucketsPerRun:
    Type: Number
    Default: 50
    Description: Safety limit. 0 means no limit. Recommended first cleanup batch is 25 to 100.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other buckets when blocked by IAM/SCP/bucket policy.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule for recurring runs.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ProjectName}-${AWS::AccountId}-${AWS::Region}-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: S3BaselineLifecyclePolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: ListBuckets
                Effect: Allow
                Action:
                  - s3:ListAllMyBuckets
                Resource: '*'
              - Sid: ReadBucketLifecycleLocationAndTags
                Effect: Allow
                Action:
                  - s3:GetBucketLocation
                  - s3:GetLifecycleConfiguration
                  - s3:GetBucketTagging
                Resource: 'arn:aws:s3:::*'
              - Sid: PutBucketLifecycle
                Effect: Allow
                Action:
                  - s3:PutLifecycleConfiguration
                Resource: 'arn:aws:s3:::*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific remediation Lambda for applying baseline S3 lifecycle configuration.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          DRY_RUN: !Ref DryRun
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          ONLY_IF_NO_LIFECYCLE: !Ref OnlyIfNoLifecycle
          ALLOW_OVERWRITE_EXISTING_LIFECYCLE: !Ref AllowOverwriteExistingLifecycle
          ABORT_INCOMPLETE_MULTIPART_UPLOAD_DAYS: !Ref AbortIncompleteMultipartUploadDays
          LIFECYCLE_RULE_ID: !Ref LifecycleRuleId
          EXCLUDED_BUCKET_NAME_PREFIXES_CSV: !Ref ExcludedBucketNamePrefixesCsv
          INCLUDED_BUCKET_NAME_PREFIXES_CSV: !Ref IncludedBucketNamePrefixesCsv
          MAX_BUCKETS_PER_RUN: !Ref MaxBucketsPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now_iso():
              return datetime.now(timezone.utc).isoformat()

          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ('1', 'true', 'yes', 'y')

          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def tags_to_dict(tag_set):
              result = {}
              for tag in tag_set or []:
                  key = tag.get('Key')
                  if key:
                      result[key] = tag.get('Value', '')
              return result

          def bucket_region(s3_global, bucket_name):
              response = s3_global.get_bucket_location(Bucket=bucket_name)
              region = response.get('LocationConstraint')
              if region in (None, ''):
                  return 'us-east-1'
              if region == 'EU':
                  return 'eu-west-1'
              return region

          def get_bucket_tags(s3_client, bucket_name):
              try:
                  response = s3_client.get_bucket_tagging(Bucket=bucket_name)
                  return tags_to_dict(response.get('TagSet', []))
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', '')
                  if code in ('NoSuchTagSet', 'NoSuchBucket'):
                      return {}
                  raise

          def get_lifecycle_status(s3_client, bucket_name):
              try:
                  response = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name)
                  return True, response.get('Rules', []), None
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', '')
                  if code in ('NoSuchLifecycleConfiguration', 'NoSuchBucket'):
                      return False, [], None
                  return None, [], e

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def prefix_match_any(name, prefixes):
              if not prefixes:
                  return False
              return any(name.startswith(prefix) for prefix in prefixes)

          def included_by_prefix(name, include_prefixes):
              if not include_prefixes:
                  return True
              return any(name.startswith(prefix) for prefix in include_prefixes)

          def baseline_lifecycle_config(settings):
              return {
                  'Rules': [
                      {
                          'ID': settings['lifecycle_rule_id'],
                          'Status': 'Enabled',
                          'Filter': {'Prefix': ''},
                          'AbortIncompleteMultipartUpload': {
                              'DaysAfterInitiation': settings['abort_incomplete_multipart_upload_days']
                          }
                      }
                  ]
              }

          def scan_buckets(settings):
              s3_global = boto3.client('s3')
              result = {
                  'evaluated': 0,
                  'buckets_without_lifecycle': 0,
                  'buckets_with_lifecycle': 0,
                  'eligible': 0,
                  'would_apply': 0,
                  'applied': 0,
                  'skipped_included_prefix_filter': 0,
                  'skipped_excluded_prefix': 0,
                  'skipped_existing_lifecycle': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'applied_buckets': []
              }

              try:
                  buckets = s3_global.list_buckets().get('Buckets', [])
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'ListBuckets', 'error_code': code, 'error_message': message})
                  result['failed'] += 1
                  return result

              for bucket in buckets:
                  if settings['max_buckets_per_run'] and (result['would_apply'] + result['applied']) >= settings['max_buckets_per_run']:
                      result['stopped_due_to_max_buckets_per_run'] = True
                      return result

                  bucket_name = bucket.get('Name')
                  if not bucket_name:
                      continue

                  if not included_by_prefix(bucket_name, settings['included_bucket_name_prefixes']):
                      result['skipped_included_prefix_filter'] += 1
                      continue

                  if prefix_match_any(bucket_name, settings['excluded_bucket_name_prefixes']):
                      result['skipped_excluded_prefix'] += 1
                      continue

                  result['evaluated'] += 1

                  try:
                      region = bucket_region(s3_global, bucket_name)
                      s3 = boto3.client('s3', region_name=region)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({'bucket': bucket_name, 'operation': 'GetBucketLocation', 'error_code': code, 'error_message': message})
                      if code in ('AccessDenied', 'AccessDeniedException') or 'access denied' in message.lower() or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise
                      continue

                  try:
                      tag_map = get_bucket_tags(s3, bucket_name)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      if code in ('AccessDenied', 'AccessDeniedException') or 'access denied' in message.lower() or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                          result['failures'].append({'bucket': bucket_name, 'region': region, 'operation': 'GetBucketTagging', 'error_code': code, 'error_message': message})
                          if not settings['continue_on_access_denied']:
                              raise
                          continue
                      else:
                          tag_map = {}

                  ignore, ignore_reason = has_ignore_tag(tag_map, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                  if ignore:
                      result['skipped_ignore_tag'] += 1
                      result['candidates'].append({'bucket': bucket_name, 'region': region, 'action': 'skipped_ignore_tag', 'reason': ignore_reason, 'tags': tag_map})
                      continue

                  if not has_approval_tag(tag_map, settings):
                      result['skipped_missing_approval_tag'] += 1
                      result['candidates'].append({'bucket': bucket_name, 'region': region, 'action': 'skipped_missing_approval_tag', 'required_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'], 'tags': tag_map})
                      continue

                  has_lifecycle, rules, lifecycle_error = get_lifecycle_status(s3, bucket_name)
                  if lifecycle_error:
                      code = lifecycle_error.response.get('Error', {}).get('Code', 'Unknown')
                      message = lifecycle_error.response.get('Error', {}).get('Message', str(lifecycle_error))
                      result['failures'].append({'bucket': bucket_name, 'region': region, 'operation': 'GetBucketLifecycleConfiguration', 'error_code': code, 'error_message': message})
                      if code in ('AccessDenied', 'AccessDeniedException') or 'access denied' in message.lower() or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise lifecycle_error
                      continue

                  if has_lifecycle:
                      result['buckets_with_lifecycle'] += 1
                      if settings['only_if_no_lifecycle'] and not settings['allow_overwrite_existing_lifecycle']:
                          result['skipped_existing_lifecycle'] += 1
                          continue
                  else:
                      result['buckets_without_lifecycle'] += 1

                  if has_lifecycle and not settings['allow_overwrite_existing_lifecycle']:
                      result['skipped_existing_lifecycle'] += 1
                      continue

                  result['eligible'] += 1
                  config = baseline_lifecycle_config(settings)
                  candidate = {
                      'bucket': bucket_name,
                      'region': region,
                      'had_existing_lifecycle': bool(has_lifecycle),
                      'new_lifecycle_configuration': config,
                      'action': 'would_apply' if settings['dry_run'] else 'apply'
                  }

                  if settings['dry_run']:
                      result['would_apply'] += 1
                      result['candidates'].append(candidate)
                      continue

                  try:
                      s3.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=config)
                      result['applied'] += 1
                      result['applied_buckets'].append(candidate)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({'bucket': bucket_name, 'region': region, 'operation': 'PutBucketLifecycleConfiguration', 'error_code': code, 'error_message': message})
                      if code in ('AccessDenied', 'AccessDeniedException') or 'access denied' in message.lower() or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise

              return result

          def handler(event, context):
              settings = {
                  'dry_run': env_bool('DRY_RUN', True),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', True),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'only_if_no_lifecycle': env_bool('ONLY_IF_NO_LIFECYCLE', True),
                  'allow_overwrite_existing_lifecycle': env_bool('ALLOW_OVERWRITE_EXISTING_LIFECYCLE', False),
                  'abort_incomplete_multipart_upload_days': env_int('ABORT_INCOMPLETE_MULTIPART_UPLOAD_DAYS', 7),
                  'lifecycle_rule_id': os.environ.get('LIFECYCLE_RULE_ID', 'CostOpt-AbortIncompleteMultipartUploads'),
                  'excluded_bucket_name_prefixes': split_csv(os.environ.get('EXCLUDED_BUCKET_NAME_PREFIXES_CSV', '')),
                  'included_bucket_name_prefixes': split_csv(os.environ.get('INCLUDED_BUCKET_NAME_PREFIXES_CSV', '')),
                  'max_buckets_per_run': env_int('MAX_BUCKETS_PER_RUN', 50),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'max_buckets_per_run' in event:
                      settings['max_buckets_per_run'] = int(event['max_buckets_per_run'])
                  if 'abort_incomplete_multipart_upload_days' in event:
                      settings['abort_incomplete_multipart_upload_days'] = int(event['abort_incomplete_multipart_upload_days'])
                  if 'included_bucket_name_prefixes' in event:
                      settings['included_bucket_name_prefixes'] = list(event['included_bucket_name_prefixes'])
                  if 'excluded_bucket_name_prefixes' in event:
                      settings['excluded_bucket_name_prefixes'] = list(event['excluded_bucket_name_prefixes'])

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'only_if_no_lifecycle': settings['only_if_no_lifecycle'],
                  'allow_overwrite_existing_lifecycle': settings['allow_overwrite_existing_lifecycle'],
                  'abort_incomplete_multipart_upload_days': settings['abort_incomplete_multipart_upload_days'],
                  'lifecycle_rule_id': settings['lifecycle_rule_id'],
                  'included_bucket_name_prefixes': settings['included_bucket_name_prefixes'],
                  'excluded_bucket_name_prefixes': settings['excluded_bucket_name_prefixes'],
                  'ignore_tag_keys': settings['ignore_tag_keys'],
                  'ignore_tag_pairs': settings['ignore_tag_pairs'],
                  'max_buckets_per_run': settings['max_buckets_per_run'],
                  'totals': {},
              }

              try:
                  result = scan_buckets(settings)
                  summary['totals'] = result
              except Exception as e:
                  summary['totals'] = {
                      'evaluated': 0,
                      'failed': 1,
                      'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}]
                  }
              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))
              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for S3 Baseline Lifecycle Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: S3BaselineLifecycleRemediatorTarget
          Input: '{"source":"eventbridge-schedule"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  RequireApprovalTagSetting:
    Description: Current RequireApprovalTag setting.
    Value: !Ref RequireApprovalTag

  OnlyIfNoLifecycleSetting:
    Description: Current OnlyIfNoLifecycle setting.
    Value: !Ref OnlyIfNoLifecycle

  ManualDryRunExample:
    Description: Example manual dry-run invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true}'' response.json'

  ManualBatchApplyDryRunExample:
    Description: Example S3 baseline lifecycle batch dry-run payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"require_approval_tag":false,"max_buckets_per_run":25}'' response.json'
